package gitbucket.core.service

import com.github.difflib.DiffUtils
import com.github.difflib.patch.DeltaType
import gitbucket.core.api.JsonFormat
import gitbucket.core.controller.Context
import gitbucket.core.model.Profile.*
import gitbucket.core.model.Profile.profile.blockingApi.*
import gitbucket.core.model.activity.OpenPullRequestInfo
import gitbucket.core.model.{CommitComments => _, Session => _, *}
import gitbucket.core.plugin.PluginRegistry
import gitbucket.core.service.RepositoryService.RepositoryInfo
import gitbucket.core.service.SystemSettingsService.SystemSettings
import gitbucket.core.util.Directory.*
import gitbucket.core.util.Implicits.*
import gitbucket.core.util.JGitUtil
import gitbucket.core.util.JGitUtil.{CommitInfo, DiffInfo, getBranchesNoMergeInfo}
import gitbucket.core.util.StringUtil.*
import gitbucket.core.view
import gitbucket.core.view.helpers
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.ObjectId

import scala.jdk.CollectionConverters.*
import scala.util.Using

trait PullRequestService {
    self: IssuesService
        with CommitsService
        with WebHookService
        with WebHookPullRequestService
        with RepositoryService
        with MergeService
        with ActivityService =>
    import PullRequestService.*

    def getPullRequest(owner: String, repository: String, issueId: Int)(implicit
        s: Session
    ): Option[(Issue, PullRequest)] = getIssue(owner, repository, issueId.toString)
        .flatMap { issue =>
            PullRequests.filter(_.byPrimaryKey(owner, repository, issueId)).firstOption.map {
                pullreq => (issue, pullreq)
            }
        }

    def updateCommitId(
        owner: String,
        repository: String,
        issueId: Int,
        commitIdTo: String,
        commitIdFrom: String
    )(implicit s: Session): Unit = PullRequests.filter(_.byPrimaryKey(owner, repository, issueId))
        .map(pr => pr.commitIdTo -> pr.commitIdFrom).update((commitIdTo, commitIdFrom))

    def updateDraftToPullRequest(owner: String, repository: String, issueId: Int)(implicit
        s: Session
    ): Unit = PullRequests.filter(_.byPrimaryKey(owner, repository, issueId)).map(pr => pr.isDraft)
        .update(false)

    def updateBaseBranch(
        owner: String,
        repository: String,
        issueId: Int,
        baseBranch: String,
        commitIdTo: String
    )(implicit s: Session): Unit = {
        PullRequests.filter(_.byPrimaryKey(owner, repository, issueId))
            .map(pr => pr.branch -> pr.commitIdTo).update((baseBranch, commitIdTo))
    }

    def getPullRequestCountGroupByUser(
        closed: Boolean,
        owner: Option[String],
        repository: Option[String]
    )(implicit s: Session): List[PullRequestCount] = PullRequests.join(Issues).on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
    }.filter { case (t1, t2) =>
        (t2.closed === closed.bind).&&(t1.userName === owner.get.bind, owner.isDefined)
            .&&(t1.repositoryName === repository.get.bind, repository.isDefined)
    }.groupBy { case (t1, t2) => t2.openedUserName }.map { case (userName, t) =>
        userName -> t.length
    }.sortBy(_._2 desc).list.map { x => PullRequestCount(x._1, x._2) }

//  def getAllPullRequestCountGroupByUser(closed: Boolean, userName: String)(implicit s: Session): List[PullRequestCount] =
//    PullRequests
//      .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) }
//      .innerJoin(Repositories).on { case ((t1, t2), t3) => t2.byRepository(t3.userName, t3.repositoryName) }
//      .filter { case ((t1, t2), t3) =>
//        (t2.closed === closed.bind) &&
//          (
//            (t3.isPrivate === false.bind) ||
//            (t3.userName  === userName.bind) ||
//            (Collaborators.filter { t4 => t4.byRepository(t3.userName, t3.repositoryName) && (t4.collaboratorName === userName.bind)} exists)
//          )
//      }
//      .groupBy { case ((t1, t2), t3) => t2.openedUserName }
//      .map { case (userName, t) => userName -> t.length }
//      .sortBy(_._2 desc)
//      .list
//      .map { x => PullRequestCount(x._1, x._2) }

    def createPullRequest(
        originRepository: RepositoryInfo,
        issueId: Int,
        originBranch: String,
        requestUserName: String,
        requestRepositoryName: String,
        requestBranch: String,
        commitIdFrom: String,
        commitIdTo: String,
        isDraft: Boolean,
        loginAccount: Account,
        settings: SystemSettings
    )(implicit s: Session, context: Context): Unit = {
        getIssue(originRepository.owner, originRepository.name, issueId.toString).foreach {
            baseIssue =>
                PullRequests insert PullRequest(
                  originRepository.owner,
                  originRepository.name,
                  issueId,
                  originBranch,
                  requestUserName,
                  requestRepositoryName,
                  requestBranch,
                  commitIdFrom,
                  commitIdTo,
                  isDraft
                )

                // fetch requested branch
                fetchAsPullRequest(
                  originRepository.owner,
                  originRepository.name,
                  requestUserName,
                  requestRepositoryName,
                  requestBranch,
                  issueId
                )

                // record activity
                val openPullRequestInfo = OpenPullRequestInfo(
                  originRepository.owner,
                  originRepository.name,
                  loginAccount.userName,
                  issueId,
                  baseIssue.title
                )
                recordActivity(openPullRequestInfo)

                // call web hook
                callPullRequestWebHook("opened", originRepository, issueId, loginAccount, settings)

                getIssue(originRepository.owner, originRepository.name, issueId.toString) foreach {
                    issue =>
                        // extract references and create refer comment
                        createReferComment(
                          originRepository.owner,
                          originRepository.name,
                          issue,
                          baseIssue.title + " " + baseIssue.content,
                          loginAccount
                        )

                        // call hooks
                        PluginRegistry().getPullRequestHooks
                            .foreach(_.created(issue, originRepository))
                }
        }
    }

    def getPullRequestsByRequest(
        userName: String,
        repositoryName: String,
        branch: String,
        closed: Option[Boolean]
    )(implicit s: Session): List[PullRequest] = PullRequests.join(Issues).on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
    }.filter { case (t1, t2) =>
        (t1.requestUserName === userName.bind).&&(t1.requestRepositoryName === repositoryName.bind)
            .&&(t1.requestBranch === branch.bind)
            .&&(t2.closed === closed.get.bind, closed.isDefined)
    }.map { case (t1, t2) => t1 }.list

    def getPullRequestsByBranch(
        userName: String,
        repositoryName: String,
        branch: String,
        closed: Option[Boolean]
    )(implicit s: Session): List[PullRequest] = PullRequests.join(Issues).on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
    }.filter { case (t1, t2) =>
        (t1.requestUserName === userName.bind).&&(t1.requestRepositoryName === repositoryName.bind)
            .&&(t1.branch === branch.bind).&&(t2.closed === closed.get.bind, closed.isDefined)
    }.map { case (t1, t2) => t1 }.list

    /**
   * for repository viewer.
   * 1. find pull request from `branch` to other branch on same repository
   *   1. return if exists pull request to `defaultBranch`
   *   2. return if exists pull request to other branch
   * 2. return None
   */
    def getPullRequestFromBranch(
        userName: String,
        repositoryName: String,
        branch: String,
        defaultBranch: String
    )(implicit s: Session): Option[(PullRequest, Issue)] = PullRequests.join(Issues)
        .on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) }
        .filter { case (t1, t2) =>
            (t1.requestUserName === userName.bind) &&
            (t1.requestRepositoryName === repositoryName.bind) &&
            (t1.requestBranch === branch.bind) &&
            (t1.userName === userName.bind) &&
            (t1.repositoryName === repositoryName.bind) &&
            (t2.closed === false.bind)
        }.sortBy { case (t1, t2) => t1.branch =!= defaultBranch.bind }.firstOption

    /**
   * Fetch pull request contents into refs/pull/${issueId}/head and update pull request table.
   */
    def updatePullRequests(
        owner: String,
        repository: String,
        branch: String,
        pusherAccount: Account,
        action: String,
        settings: SystemSettings
    )(implicit s: Session, c: JsonFormat.Context): Unit = {
        getPullRequestsByRequest(owner, repository, branch, Some(false)).foreach { pullreq =>
            if (
              Repositories.filter(_.byRepository(pullreq.userName, pullreq.repositoryName)).exists
                  .run
            ) {
                // Update the git repository
                val (commitIdTo, commitIdFrom) = JGitUtil.updatePullRequest(
                  pullreq.userName,
                  pullreq.repositoryName,
                  pullreq.branch,
                  pullreq.issueId,
                  pullreq.requestUserName,
                  pullreq.requestRepositoryName,
                  pullreq.requestBranch
                )

                // Collect comment positions
                val positions = getCommitComments(
                  pullreq.userName,
                  pullreq.repositoryName,
                  pullreq.commitIdTo,
                  true
                ).collect {
                    case CommitComment(
                          _,
                          _,
                          _,
                          commentId,
                          _,
                          _,
                          Some(file),
                          None,
                          Some(newLine),
                          _,
                          _,
                          _,
                          _,
                          _,
                          _
                        ) => (file, commentId, Right(newLine))
                    case CommitComment(
                          _,
                          _,
                          _,
                          commentId,
                          _,
                          _,
                          Some(file),
                          Some(oldLine),
                          None,
                          _,
                          _,
                          _,
                          _,
                          _,
                          _
                        ) => (file, commentId, Left(oldLine))
                }.groupBy { case (file, _, _) => file }.map { case (file, comments) =>
                    file -> comments.map { case (_, commentId, lineNumber) =>
                        (commentId, lineNumber)
                    }
                }

                // Update comments position
                updatePullRequestCommentPositions(
                  positions,
                  pullreq.requestUserName,
                  pullreq.requestRepositoryName,
                  pullreq.commitIdTo,
                  commitIdTo,
                  settings
                )

                // Update commit id in the PULL_REQUEST table
                updateCommitId(
                  pullreq.userName,
                  pullreq.repositoryName,
                  pullreq.issueId,
                  commitIdTo,
                  commitIdFrom
                )

                // call web hook
                callPullRequestWebHookByRequestBranch(
                  action,
                  getRepository(owner, repository).get,
                  pullreq.requestBranch,
                  pusherAccount,
                  settings
                )
            }
        }
    }

    def updatePullRequestsByApi(
        repository: RepositoryInfo,
        issueId: Int,
        loginAccount: Account,
        settings: SystemSettings,
        title: Option[String],
        body: Option[String],
        state: Option[String],
        base: Option[String]
    )(implicit s: Session, c: JsonFormat.Context): Unit = {
        getPullRequest(repository.owner, repository.name, issueId).foreach { case (issue, pr) =>
            if (Repositories.filter(_.byRepository(pr.userName, pr.repositoryName)).exists.run) {
                // Update base branch
                base.foreach { _base =>
                    if (pr.branch != _base) {
                        Using.resource(
                          Git.open(getRepositoryDir(repository.owner, repository.name))
                        ) { git =>
                            getBranchesNoMergeInfo(git, repository.repository.defaultBranch)
                                .find(_.name == _base).foreach(br =>
                                    updateBaseBranch(
                                      repository.owner,
                                      repository.name,
                                      issueId,
                                      br.name,
                                      br.commitId
                                    )
                                )
                        }
                        createComment(
                          repository.owner,
                          repository.name,
                          loginAccount.userName,
                          issue.issueId,
                          pr.branch + "\r\n" + _base,
                          "change_base_branch"
                        )
                    }
                }
                // Update title and content
                title.foreach { _title =>
                    updateIssue(repository.owner, repository.name, issueId, _title, body)
                    if (issue.title != _title) {
                        createComment(
                          repository.owner,
                          repository.name,
                          loginAccount.userName,
                          issue.issueId,
                          issue.title + "\r\n" + _title,
                          "change_title"
                        )
                    }
                }
                // Update state
                val action = (state, issue.closed) match {
                    case (Some("open"), true) =>
                        updateClosed(repository.owner, repository.name, issueId, closed = false)
                        "reopened"
                    case (Some("closed"), false) =>
                        updateClosed(repository.owner, repository.name, issueId, closed = true)
                        "closed"
                    case _ => "edited"
                }
                // Call web hook
                callPullRequestWebHookByRequestBranch(
                  action,
                  getRepository(repository.owner, repository.name).get,
                  pr.requestBranch,
                  loginAccount,
                  settings
                )
            }
        }
    }

    def getPullRequestByRequestCommit(
        userName: String,
        repositoryName: String,
        toBranch: String,
        fromBranch: String,
        commitId: String
    )(implicit s: Session): Option[(PullRequest, Issue)] = {
        if (toBranch == fromBranch) { None }
        else {
            PullRequests.join(Issues).on { (t1, t2) =>
                t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
            }.filter { case (t1, t2) =>
                (t1.userName === userName.bind) &&
                (t1.repositoryName === repositoryName.bind) &&
                (t1.branch === toBranch.bind) &&
                (t1.requestUserName === userName.bind) &&
                (t1.requestRepositoryName === repositoryName.bind) &&
                (t1.requestBranch === fromBranch.bind) &&
                (t1.commitIdTo === commitId.bind)
            }.firstOption
        }
    }

    private def updatePullRequestCommentPositions(
        positions: Map[String, Seq[(Int, Either[Int, Int])]],
        userName: String,
        repositoryName: String,
        oldCommitId: String,
        newCommitId: String,
        settings: SystemSettings
    )(implicit s: Session): Unit = {

        val (_, diffs) = getRequestCompareInfo(
          userName,
          repositoryName,
          oldCommitId,
          userName,
          repositoryName,
          newCommitId,
          settings
        )

        val patchs = positions.map { case (file, _) =>
            diffs.find(x => x.oldPath == file).map { diff =>
                (diff.oldContent, diff.newContent) match {
                    case (Some(oldContent), Some(newContent)) => {
                        val oldLines = convertLineSeparator(oldContent, "LF").split("\n")
                        val newLines = convertLineSeparator(newContent, "LF").split("\n")
                        file ->
                            Option(DiffUtils.diff(oldLines.toList.asJava, newLines.toList.asJava))
                    }
                    case _ => file -> None
                }
            }.getOrElse { file -> None }
        }

        positions.foreach { case (file, comments) =>
            patchs(file) match {
                case Some(patch) => file -> comments.foreach { case (commentId, lineNumber) =>
                        lineNumber match {
                            case Left(oldLine) => updateCommitCommentPosition(
                                  commentId,
                                  newCommitId,
                                  Some(oldLine),
                                  None
                                )
                            case Right(newLine) =>
                                var counter = newLine
                                patch.getDeltas.asScala.filter(_.getSource.getPosition < newLine)
                                    .foreach { delta =>
                                        delta.getType match {
                                            case DeltaType.CHANGE =>
                                                if (
                                                  delta.getSource.getPosition <= newLine - 1 &&
                                                  newLine <=
                                                      delta.getSource.getPosition +
                                                      delta.getTarget.getLines.size
                                                ) { counter = -1 }
                                                else {
                                                    counter = counter +
                                                        (delta.getTarget.getLines.size -
                                                            delta.getSource.getLines.size)
                                                }
                                            case DeltaType.INSERT =>
                                                counter = counter + delta.getTarget.getLines.size
                                            case DeltaType.DELETE =>
                                                counter = counter - delta.getSource.getLines.size
                                            case DeltaType.EQUAL => // Do nothing
                                        }
                                    }
                                if (counter >= 0) {
                                    updateCommitCommentPosition(
                                      commentId,
                                      newCommitId,
                                      None,
                                      Some(counter)
                                    )
                                }
                        }
                    }
                case _ => comments.foreach { case (commentId, lineNumber) =>
                        lineNumber match {
                            case Right(oldLine) => updateCommitCommentPosition(
                                  commentId,
                                  newCommitId,
                                  Some(oldLine),
                                  None
                                )
                            case Left(newLine) => updateCommitCommentPosition(
                                  commentId,
                                  newCommitId,
                                  None,
                                  Some(newLine)
                                )
                        }
                    }
            }
        }
    }

    def getSingleDiff(
        userName: String,
        repositoryName: String,
        commitId: String,
        path: String
    ): Option[DiffInfo] = {
        Using.resource(Git.open(getRepositoryDir(userName, repositoryName))) { git =>
            val newId = git.getRepository.resolve(commitId)
            JGitUtil.getDiff(git, None, newId.getName, path)
        }
    }

    def getSingleDiff(
        userName: String,
        repositoryName: String,
        branch: String,
        requestUserName: String,
        requestRepositoryName: String,
        requestCommitId: String,
        path: String
    ): Option[DiffInfo] = {
        Using.resources(
          Git.open(getRepositoryDir(userName, repositoryName)),
          Git.open(getRepositoryDir(requestUserName, requestRepositoryName))
        ) { (oldGit, newGit) =>
            val oldId = oldGit.getRepository.resolve(branch)
            val newId = newGit.getRepository.resolve(requestCommitId)

            JGitUtil.getDiff(newGit, Some(oldId.getName), newId.getName, path)
        }
    }

    def getRequestCompareInfo(
        userName: String,
        repositoryName: String,
        branch: String,
        requestUserName: String,
        requestRepositoryName: String,
        requestCommitId: String,
        settings: SystemSettings
    ): (Seq[Seq[CommitInfo]], Seq[DiffInfo]) = Using.resources(
      Git.open(getRepositoryDir(userName, repositoryName)),
      Git.open(getRepositoryDir(requestUserName, requestRepositoryName))
    ) { (oldGit, newGit) =>
        val oldId = oldGit.getRepository.resolve(branch)
        val newId = newGit.getRepository.resolve(requestCommitId)

        val commits = newGit.log.addRange(oldId, newId).call.iterator.asScala.map { revCommit =>
            new CommitInfo(revCommit)
        }.toList.splitWith { (commit1, commit2) =>
            helpers.date(commit1.commitTime) == view.helpers.date(commit2.commitTime)
        }

        // TODO Isolate to an another method?
        val diffs = JGitUtil.getDiffs(
          git = newGit,
          from = Some(oldId.getName),
          to = newId.getName,
          fetchContent = true,
          makePatch = false,
          maxFiles = settings.repositoryViewer.maxDiffFiles,
          maxLines = settings.repositoryViewer.maxDiffLines
        )

        (commits, diffs)
    }

    def getPullRequestComments(
        userName: String,
        repositoryName: String,
        issueId: Int,
        commits: Seq[CommitInfo]
    )(implicit s: Session): Seq[Comment] = {
        (commits.flatMap(commit => getCommitComments(userName, repositoryName, commit.id, true)) ++
            getComments(userName, repositoryName, issueId)).groupBy {
            case x: IssueComment                        => (Some(x.commentId), None, None, None)
            case x: CommitComment if x.fileName.isEmpty => (Some(x.commentId), None, None, None)
            case x: CommitComment => (None, x.fileName, x.originalOldLine, x.originalNewLine)
            case x                => throw new MatchError(x)
        }.toSeq.map {
            // Normal comment
            case ((Some(_), _, _, _), comments) => comments.head
            // Comment on a specific line of a commit
            case ((None, Some(fileName), oldLine, newLine), comments) => gitbucket.core.model
                    .CommitComments(
                      fileName = fileName,
                      commentedUserName = comments.head.commentedUserName,
                      registeredDate = comments.head.registeredDate,
                      comments = comments.map(_.asInstanceOf[CommitComment]),
                      diff = loadCommitCommentDiff(
                        userName,
                        repositoryName,
                        comments.head.asInstanceOf[CommitComment].originalCommitId,
                        fileName,
                        oldLine,
                        newLine
                      )
                    )
        }.sortWith(_.registeredDate before _.registeredDate)
    }

    def markMergeAndClosePullRequest(
        userName: String,
        owner: String,
        repository: String,
        pull: PullRequest
    )(implicit s: Session): Unit = {
        createComment(owner, repository, userName, pull.issueId, "Merged by user", "merge")
        createComment(owner, repository, userName, pull.issueId, "Close", "close")
        updateClosed(owner, repository, pull.issueId, true)
    }

    /**
   * Parses branch identifier and extracts owner and branch name as tuple.
   *
   * - "owner:branch" to ("owner", "branch")
   * - "branch" to ("defaultOwner", "branch")
   */
    def parseCompareIdentifier(value: String, defaultOwner: String): (String, String) =
        if (value.contains(':')) {
            val array = value.split(":")
            (array(0), array(1))
        } else { (defaultOwner, value) }

    def getPullRequestCommitFromTo(
        originRepository: RepositoryInfo,
        forkedRepository: RepositoryInfo,
        originId: String,
        forkedId: String
    ): (Option[ObjectId], Option[ObjectId]) = {
        Using.resources(
          Git.open(getRepositoryDir(originRepository.owner, originRepository.name)),
          Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))
        ) { case (oldGit, newGit) =>
            if (originRepository.branchList.contains(originId)) {
                val forkedId2 = forkedRepository.tags
                    .collectFirst { case x if x.name == forkedId => x.commitId }.getOrElse(forkedId)

                val originId2 = JGitUtil.getForkedCommitId(
                  oldGit,
                  newGit,
                  originRepository.owner,
                  originRepository.name,
                  originId,
                  forkedRepository.owner,
                  forkedRepository.name,
                  forkedId2
                )

                (
                  Option(oldGit.getRepository.resolve(originId2)),
                  Option(newGit.getRepository.resolve(forkedId2))
                )

            } else {
                val originId2 = originRepository.tags
                    .collectFirst { case x if x.name == originId => x.commitId }.getOrElse(originId)
                val forkedId2 = forkedRepository.tags
                    .collectFirst { case x if x.name == forkedId => x.commitId }.getOrElse(forkedId)

                (
                  Option(oldGit.getRepository.resolve(originId2)),
                  Option(newGit.getRepository.resolve(forkedId2))
                )
            }
        }
    }
}

object PullRequestService {

    val PullRequestLimit = 25

    case class PullRequestCount(userName: String, count: Int)

    case class MergeStatus(
        conflictMessage: Option[String],
        commitStatuses: List[CommitStatus],
        branchProtection: ProtectedBranchService.ProtectedBranchInfo,
        branchIsOutOfDate: Boolean,
        hasUpdatePermission: Boolean,
        needStatusCheck: Boolean,
        hasMergePermission: Boolean,
        commitIdTo: String
    ) {

        val hasConflict = conflictMessage.isDefined
        val statuses: List[CommitStatus] = commitStatuses ++
            (branchProtection.contexts.toSet -- commitStatuses.map(_.context).toSet)
                .map(CommitStatus.pending(branchProtection.owner, branchProtection.repository, _))
        val hasRequiredStatusProblem = needStatusCheck &&
            branchProtection.contexts.exists(context =>
                statuses.find(_.context == context).map(_.state) != Some(CommitState.SUCCESS)
            )
        val hasProblem = hasRequiredStatusProblem || hasConflict ||
            (statuses.nonEmpty &&
                CommitState.combine(statuses.map(_.state).toSet) != CommitState.SUCCESS)
        val canUpdate = branchIsOutOfDate && !hasConflict
        val canMerge = hasMergePermission && !hasConflict && !hasRequiredStatusProblem
        lazy val commitStateSummary: (CommitState, String) = {
            val stateMap = statuses.groupBy(_.state)
            val state = CommitState.combine(stateMap.keySet)
            val summary = stateMap.map { case (keyState, states) =>
                s"${states.size} ${keyState.name}"
            }.mkString(", ")
            state -> summary
        }
        lazy val statusesAndRequired: List[(CommitStatus, Boolean)] = statuses.map { s =>
            s -> branchProtection.contexts.contains(s.context)
        }
        lazy val isAllSuccess = commitStateSummary._1 == CommitState.SUCCESS
    }
}
